﻿// ===============================================================================
//  Copyright © ymind.net. All rights reserved .
//  官方网站：http://ymind.net/
//  版权所有：彦铭工作室 老陈出品
// ===============================================================================

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
using Lcsoft.RssEngine.Collections;
using Lcsoft.RssEngine.Shared;

namespace Lcsoft.RssEngine
{
	/// <summary>Writes an RSS XML file.</summary>
	/// <remarks>Represents a writer that provides a fast, non-cached, forward-only way of generating streams or files containing RSS XML data that conforms to the W3C Extensible Markup Language (XML) 1.0 and the Namespaces in XML recommendations.</remarks>
	public class RssWriter
	{
		private const string _DATE_TIME_FORMAT_STRING = "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'";

		// modules
		private XmlTextWriter _writer;

		// functional var
		private bool _wroteChannel;
		private bool _wroteStartDocument;

		#region Constructors

		/// <summary>Creates an instance of the RssWriter class using the specified TextWriter.</summary>
		/// <param name="textWriter">specified TextWriter</param>
		public RssWriter(TextWriter textWriter)
		{
			this.Version = RssVersion.Rss20;
			this.XmlFormat = Formatting.Indented;
			this.XmlIndentation = 1;
			this.XmlIndentChar = '\t';
			this.Modules = new RssModuleCollection();
			this._writer = new XmlTextWriter(textWriter);
		}

		/// <summary>Creates an instance of the RssWriter class using the specified Stream and Encoding.</summary>
		/// <exception cref="ArgumentException">The encoding is not supported or the stream cannot be written to.</exception>
		/// <param name="stream">Stream to output to</param>
		/// <param name="encoding">The encoding to use. If encoding is (null c#, Nothing vb) it writes out the stream as UTF-8.</param>
		public RssWriter(Stream stream, Encoding encoding)
		{
			this.Version = RssVersion.Rss20;
			this.XmlFormat = Formatting.Indented;
			this.XmlIndentation = 1;
			this.XmlIndentChar = '\t';
			this.Modules = new RssModuleCollection();
			this._writer = new XmlTextWriter(stream, encoding);
		}

		/// <summary>Creates an instance of the RssWriter class using the specified Stream.</summary>
		/// <remarks>The encoding is UTF8.</remarks>
		/// <exception cref="ArgumentException">The Stream cannot be written to.</exception>
		/// <param name="stream">specified Stream</param>
		public RssWriter(Stream stream)
		{
			this.Version = RssVersion.Rss20;
			this.XmlFormat = Formatting.Indented;
			this.XmlIndentation = 1;
			this.XmlIndentChar = '\t';
			this.Modules = new RssModuleCollection();
			this._writer = new XmlTextWriter(stream, Encoding.UTF8);
		}

		/// <summary>Creates an instance of the RssWriter class using the specified file and Encoding.</summary>
		/// <exception cref="ArgumentException">The encoding is not supported; the filename is empty, contains only white space, or contains one or more invalid characters.</exception>
		/// <exception cref="UnauthorizedAccessException">Access is denied.</exception>
		/// <exception cref="ArgumentNullException">The filename is a (null c#, Nothing vb) reference.</exception>
		/// <exception cref="DirectoryNotFoundException">The directory to write to is not found.</exception>
		/// <exception cref="IOException">The filename includes an incorrect or invalid syntax for file name, directory name, or volume label syntax.</exception>
		/// <exception cref="System.Security.SecurityException">The caller does not have the required permission.</exception>
		/// <param name="fileName">specified file (including path) If the file exists, it will be truncated with the new content.</param>
		/// <param name="encoding">specified Encoding</param>
		public RssWriter(string fileName, Encoding encoding)
		{
			this.Version = RssVersion.Rss20;
			this.XmlFormat = Formatting.Indented;
			this.XmlIndentation = 1;
			this.XmlIndentChar = '\t';
			this.Modules = new RssModuleCollection();
			this._writer = new XmlTextWriter(fileName, encoding);
		}

		/// <summary>Creates an instance of the RssWriter class using the specified file.</summary>
		/// <remarks>The encoding is UTF8.</remarks>
		/// <exception cref="ArgumentException">The filename is empty, contains only white space, or contains one or more invalid characters.</exception>
		/// <exception cref="UnauthorizedAccessException">Access is denied.</exception>
		/// <exception cref="ArgumentNullException">The filename is a (null c#, Nothing vb) reference.</exception>
		/// <exception cref="DirectoryNotFoundException">The directory to write to is not found.</exception>
		/// <exception cref="IOException">The filename includes an incorrect or invalid syntax for file name, directory name, or volume label syntax.</exception>
		/// <exception cref="System.Security.SecurityException">The caller does not have the required permission.</exception>
		/// <param name="fileName">specified file (including path) If the file exists, it will be truncated with the new content.</param>
		public RssWriter(string fileName)
		{
			this.Version = RssVersion.Rss20;
			this.XmlFormat = Formatting.Indented;
			this.XmlIndentation = 1;
			this.XmlIndentChar = '\t';
			this.Modules = new RssModuleCollection();
			this._writer = new XmlTextWriter(fileName, Encoding.UTF8);
		}

		#endregion

		// prefrences

		/// <summary>Gets or sets the RSS version to write.</summary>
		/// <exception cref="InvalidOperationException">Can't change version number after data has been written.</exception>
		public RssVersion Version { get; set; }

		/// <summary>Gets or sets the <see cref="System.Xml.Formatting"/> of the XML output.</summary>
		/// <exception cref="InvalidOperationException">Can't change XML formatting after data has been written.</exception>
		public Formatting XmlFormat { get; set; }

		/// <summary>Gets or sets how indentation to write for each level in the hierarchy when XmlFormat is set to <see cref="Formatting.Indented"/></summary>
		/// <exception cref="InvalidOperationException">Can't change XML formatting after data has been written.</exception>
		/// <exception cref="ArgumentException">Setting this property to a negative value.</exception>
		public int XmlIndentation { get; set; }

		/// <summary>
		/// 
		/// </summary>
		public Char XmlIndentChar { get; set; }

		/// <summary>RSS modules</summary>
		public RssModuleCollection Modules { get; internal set; }

		#region WriteElement

		/// <summary>Writes an element with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteElement(string localName, DateTime input, bool required)
		{
			if (input != RssDefault.DateTimeValue) this._writer.WriteElementString(localName, XmlConvert.ToString(input, _DATE_TIME_FORMAT_STRING));
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an element with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteElement(string localName, int input, bool required)
		{
			if (input != RssDefault.IntValue) this._writer.WriteElementString(localName, XmlConvert.ToString(input));
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an element with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteElement(string localName, string input, bool required)
		{
			if (input != String.Empty) this._writer.WriteElementString(localName, input);
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an element with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteElement(string localName, Uri input, bool required)
		{
			if (input != RssDefault.UriValue) this._writer.WriteElementString(localName, input.ToString());
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an element with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteElement(string localName, object input, bool required)
		{
			if (input != null) this._writer.WriteElementString(localName, input.ToString());
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		#endregion

		#region WriteAttribute

		/// <summary>Writes an attribute with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteAttribute(string localName, int input, bool required)
		{
			if (input != RssDefault.IntValue) this._writer.WriteAttributeString(localName, XmlConvert.ToString(input));
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an attribute with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteAttribute(string localName, string input, bool required)
		{
			if (input != String.Empty) this._writer.WriteAttributeString(localName, input);
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an attribute with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteAttribute(string localName, Uri input, bool required)
		{
			if (input != RssDefault.UriValue) this._writer.WriteAttributeString(localName, input.ToString());
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		/// <summary>Writes an attribute with the specified local name and value</summary>
		/// <param name="localName">the localname of the element</param>
		/// <param name="input">the value of the element</param>
		/// <param name="required">boolean that determines if input cannot be null</param>
		private void _WriteAttribute(string localName, object input, bool required)
		{
			if (input != null) this._writer.WriteAttributeString(localName, input.ToString());
			else if (required) throw new ArgumentException(string.Format("{0} can not be null.", localName));
		}

		#endregion

		#region WriteSubElements

		private void _WriteSubElements(IEnumerable<RssModuleItem> items, string namespacePrefix)
		{
			foreach (var rssModuleItem in items)
			{
				if (rssModuleItem.SubElements.Count == 0) this._WriteElement(string.Format("{0}:{1}", namespacePrefix, rssModuleItem.Name), rssModuleItem.Text, rssModuleItem.IsRequired);
				else
				{
					this._writer.WriteStartElement(string.Format("{0}:{1}", namespacePrefix, rssModuleItem.Name));
					this._WriteSubElements(rssModuleItem.SubElements, namespacePrefix);
					this._writer.WriteEndElement();
				}
			}
		}

		#endregion

		// constants

		/// <summary>Writes the begining data to the RSS file</summary>
		/// <remarks>This routine is called from the WriteChannel and WriteItem subs</remarks>
		/// <exception cref="NotSupportedException">RDF Site Summary (RSS) 1.0 is not currently supported.</exception>
		private void _BeginDocument()
		{
			if (this._wroteStartDocument) return;
			if (this.Version == RssVersion.Empty) this.Version = RssVersion.Rss20;

			this._writer.Formatting = this.XmlFormat;
			this._writer.Indentation = this.XmlIndentation;
			this._writer.IndentChar = this.XmlIndentChar;
			this._writer.WriteStartDocument();

			if (this.Version != RssVersion.Rss20) this._writer.WriteComment("Generated by RSS.NET: http://rss-net.sf.net");

			//exc: The xml:space or xml:lang attribute value is invalid.
			switch (this.Version)
			{
				case RssVersion.Rss090:
					//<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://my.netscape.com/rdf/simple/0.9/">
					this._writer.WriteStartElement("RDF", "rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#");
					break;
				case RssVersion.Rss091:
					this._writer.WriteStartElement("rss");
					this._writer.WriteDocType("rss", "-//Netscape Communications//DTD RSS 0.91//EN", "http://my.netscape.com/publish/formats/rss-0.91.dtd", null);
					this._writer.WriteAttributeString("version", "0.91");
					break;
				case RssVersion.Rss092:
					this._writer.WriteStartElement("rss");
					this._writer.WriteAttributeString("version", "0.92");
					break;
				case RssVersion.Rss10:
					throw new NotSupportedException("RDF Site Summary (RSS) 1.0 is not currently supported.");
				case RssVersion.Rss20:
					this._writer.WriteStartElement("rss");
					this._writer.WriteAttributeString("version", "2.0");
					// RSS Modules
					foreach (var rssModule in this.Modules) this._WriteAttribute(string.Format("xmlns:{0}", rssModule.NamespacePrefix), rssModule.NamespaceURL.ToString(), true);
					break;
			}
			this._wroteStartDocument = true;
		}

		private void _WriteChannel(RssChannel.RssChannel channel)
		{
			if (this._writer == null) throw new InvalidOperationException("RssWriter has been closed, and can not be written to.");
			if (channel == null) throw new ArgumentNullException("channel");

			if (this._wroteChannel) this._writer.WriteEndElement();
			else this._wroteChannel = true;

			this._BeginDocument();

			this._writer.WriteStartElement("channel");
			this._WriteElement("title", channel.Title, true);
			this._WriteElement("description", channel.Description, true);
			this._WriteElement("link", channel.Link, true);
			if (channel.Image != null)
			{
				this._writer.WriteStartElement("image");
				this._WriteElement("title", channel.Image.Title, true);
				this._WriteElement("url", channel.Image.Url, true);
				this._WriteElement("link", channel.Image.Link, true);
				switch (this.Version)
				{
					case RssVersion.Rss091:
					case RssVersion.Rss092:
					case RssVersion.Rss20:
						this._WriteElement("description", channel.Image.Description, false);
						this._WriteElement("width", channel.Image.Width, false);
						this._WriteElement("height", channel.Image.Height, false);
						break;
				}
				this._writer.WriteEndElement();
			}
			switch (this.Version)
			{
				case RssVersion.Rss091:
				case RssVersion.Rss092:
				case RssVersion.Rss20:
					this._WriteElement("language", channel.Language, this.Version == RssVersion.Rss091);
					this._WriteElement("copyright", channel.Copyright, false);
					this._WriteElement("managingEditor", channel.ManagingEditor, false);
					this._WriteElement("webMaster", channel.WebMaster, false);
					this._WriteElement("pubDate", channel.PubDate, false);
					this._WriteElement("lastBuildDate", channel.LastBuildDate, false);
					if (channel.Docs != String.Empty) this._WriteElement("docs", channel.Docs, false);
					else
					{
						switch (this.Version)
						{
							case RssVersion.Rss091:
								this._WriteElement("docs", "http://my.netscape.com/publish/formats/rss-spec-0.91.html", false);
								break;
							case RssVersion.Rss092:
								this._WriteElement("docs", "http://backend.userland.com/rss092", false);
								break;
							case RssVersion.Rss20:
								this._WriteElement("docs", "http://backend.userland.com/rss", false);
								break;
						}
					}
					this._WriteElement("rating", channel.Rating, false);
					string[] days = { "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" };
					for (var i = 0; i <= 6; i++)
					{
						if (!channel.SkipDays[i]) continue;
						this._writer.WriteStartElement("skipDays");
						for (var i2 = 0; i2 <= 6; i2++) if (channel.SkipDays[i2]) this._WriteElement("day", days[i2], false);
						this._writer.WriteEndElement();
						break;
					}
					for (var i = 0; i <= 23; i++)
					{
						if (!channel.SkipHours[i]) continue;
						this._writer.WriteStartElement("skipHours");
						for (var i2 = 0; i2 <= 23; i2++) if (channel.SkipHours[i2]) this._WriteElement("hour", i2 + 1, false);
						this._writer.WriteEndElement();
						break;
					}
					break;
			}
			switch (this.Version)
			{
				case RssVersion.Rss092:
				case RssVersion.Rss20:
					if (channel.Categories != null)
					{
						foreach (var category in channel.Categories)
						{
							if (category.Name == String.Empty) continue;
							this._writer.WriteStartElement("category");
							this._WriteAttribute("domain", category.Domain, false);
							this._writer.WriteString(category.Name);
							this._writer.WriteEndElement();
						}
					}
					if (channel.Cloud != null)
					{
						this._writer.WriteStartElement("cloud");
						this._WriteElement("domain", channel.Cloud.Domain, false);
						this._WriteElement("port", channel.Cloud.Port, false);
						this._WriteElement("path", channel.Cloud.Path, false);
						this._WriteElement("registerProcedure", channel.Cloud.RegisterProcedure, false);
						if (channel.Cloud.Protocol != RssCloudProtocol.Empty) this._WriteElement("Protocol", channel.Cloud.Protocol, false);
						this._writer.WriteEndElement();
					}
					break;
			}
			if (this.Version == RssVersion.Rss20)
			{
				this._WriteElement("generator", channel.Generator != String.Empty ? channel.Generator : "RSS.NET: http://www.rssdotnet.com/", false);
				this._WriteElement("ttl", channel.TimeToLive, false);

				// RSS Modules
				foreach (var rssModule in this.Modules)
				{
					if (!rssModule.IsBoundTo(channel.GetHashCode())) continue;
					foreach (var rssModuleItem in rssModule.ChannelExtensions)
					{
						if (rssModuleItem.SubElements.Count == 0) this._WriteElement(string.Format("{0}:{1}", rssModule.NamespacePrefix, rssModuleItem.Name), rssModuleItem.Text, rssModuleItem.IsRequired);
						else this._WriteSubElements(rssModuleItem.SubElements, rssModule.NamespacePrefix);
					}
				}
			}
			if (channel.TextInput != null)
			{
				this._writer.WriteStartElement("textinput");
				this._WriteElement("title", channel.TextInput.Title, true);
				this._WriteElement("description", channel.TextInput.Description, true);
				this._WriteElement("name", channel.TextInput.Name, true);
				this._WriteElement("link", channel.TextInput.Link, true);
				this._writer.WriteEndElement();
			}
			foreach (var item in channel.Items) this._WriteItem(item, channel.GetHashCode());
			this._writer.Flush();
		}

		private void _WriteItem(RssItem.RssItem item, int channelHashCode)
		{
			if (this._writer == null) throw new InvalidOperationException("RssWriter has been closed, and can not be written to.");
			if (item == null) throw new ArgumentNullException("item");
			if (!this._wroteChannel) throw new InvalidOperationException("Channel must be written first, before writing an item.");

			this._BeginDocument();

			this._writer.WriteStartElement("item");
			switch (this.Version)
			{
				case RssVersion.Rss090:
				case RssVersion.Rss10:
				case RssVersion.Rss091:
					this._WriteElement("title", item.Title, true);
					this._WriteElement("description", item.Description, false);
					this._WriteElement("link", item.Link, true);
					break;
				case RssVersion.Rss20:
					if ((item.Title == String.Empty) && (item.Description == String.Empty)) throw new ArgumentException("item title and description cannot be null");
					goto case RssVersion.Rss092;
				case RssVersion.Rss092:
					this._WriteElement("title", item.Title, false);
					this._WriteElement("description", item.Description, false);
					this._WriteElement("link", item.Link, false);
					if (item.Source != null)
					{
						this._writer.WriteStartElement("source");
						this._WriteAttribute("url", item.Source.Url, true);
						this._writer.WriteString(item.Source.Name);
						this._writer.WriteEndElement();
					}
					if (item.Enclosure != null)
					{
						this._writer.WriteStartElement("enclosure");
						this._WriteAttribute("url", item.Enclosure.Url, true);
						this._WriteAttribute("length", item.Enclosure.Length, true);
						this._WriteAttribute("type", item.Enclosure.Type, true);
						this._writer.WriteEndElement();
					}
					foreach (var category in item.Categories)
					{
						if (category.Name == String.Empty) continue;
						this._writer.WriteStartElement("category");
						this._WriteAttribute("domain", category.Domain, false);
						this._writer.WriteString(category.Name);
						this._writer.WriteEndElement();
					}
					break;
			}
			if (this.Version == RssVersion.Rss20)
			{
				this._WriteElement("author", item.Author, false);
				this._WriteElement("comments", item.Comments, false);
				if ((item.Guid != null) && (item.Guid.Name != String.Empty))
				{
					this._writer.WriteStartElement("guid");
					try
					{
						this._WriteAttribute("isPermaLink", (bool)item.Guid.PermaLink, false);
					}
					catch {}
					this._writer.WriteString(item.Guid.Name);
					this._writer.WriteEndElement();
				}
				this._WriteElement("pubDate", item.PubDate, false);

				foreach (var rssModule in this.Modules) if (rssModule.IsBoundTo(channelHashCode)) foreach (var rssModuleItemCollection in rssModule.ItemExtensions) if (rssModuleItemCollection.IsBoundTo(item.GetHashCode())) this._WriteSubElements(rssModuleItemCollection, rssModule.NamespacePrefix);
			}

			this._writer.WriteEndElement();
			this._writer.Flush();
		}

		/// <summary>Closes instance of RssWriter.</summary>
		/// <remarks>Writes end elements, and releases connections</remarks>
		/// <exception cref="InvalidOperationException">Occurs if the RssWriter is already closed or the caller is attempting to close before writing a channel.</exception>
		public void Close()
		{
			if (this._writer == null) throw new InvalidOperationException("RssWriter has been closed, and can not be closed again.");

			if (!this._wroteChannel) throw new InvalidOperationException("Can't close RssWriter without first writing a channel.");

			this._writer.WriteEndElement(); // </channel>
			this._writer.WriteEndElement(); // </rss> or </rdf>
			this._writer.Close();
			this._writer = null;
		}

		/// <summary>Writes an RSS channel</summary>
		/// <exception cref="InvalidOperationException">RssWriter has been closed, and can not be written to.</exception>
		/// <exception cref="ArgumentNullException">Channel must be instanciated with data, before calling Write.</exception>
		/// <param name="channel">RSS channel to write</param>
		public void Write(RssChannel.RssChannel channel) { this._WriteChannel(channel); }

		/// <summary>Writes an RSS item</summary>
		/// <exception cref="InvalidOperationException">Either the RssWriter has already been closed, or the caller is attempting to write an RSS item before an RSS channel.</exception>
		/// <exception cref="ArgumentNullException">Item must be instanciated with data, before calling Write.</exception>
		/// <param name="item">RSS item to write</param>
		public void Write(RssItem.RssItem item)
		{
			// NOTE: Standalone items cannot adhere to modules, hence -1 is passed. This may not be the case, however, no examples have been seen where this is legal.
			this._WriteItem(item, -1);
		}
	}
}
